HEX
Server: Apache/2.4.68 (Debian)
System: Linux as-cs-widget-demo-us-central1 6.1.0-44-cloud-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.164-1 (2026-03-09) x86_64
User: root (0)
PHP: 8.2.32
Disabled: NONE
Upload Files
File: /var/www/kevin-demo/wp-content/plugins/allspice/includes/membership-gate.php
<?php
// includes/membership-gate.php
//
// Server-side recipe-card gating (gate_type: recipe_card, the only gate type in v1).
//
// A recipe whose synced overview carries a non-empty `gated` array (product ids, OR rule:
// owning any listed product grants access) renders its recipe card only when the verified
// first-party member session (includes/membership.php) contains a matching product. Denied
// readers get a server-rendered gate placeholder IN PLACE of the card - the card's markup is
// never sent to the browser, so nothing client-side can reveal it. The rest of the article is
// untouched.
//
// Card location is adapter-based (req 12): each adapter knows one recipe-card provider's real
// DOM boundaries. v1 ships the WPRM adapter built on the plugin's existing boundary helpers.
// Nothing is ever matched by the word "recipe" (req 11).

if (!defined('ABSPATH')) exit;

/* ------------------------------------------------------------------------------ adapters */

/*
 * Adapter contract: ['id' => string, 'find_all' => callable(string $content): array of
 * ['start' => int, 'end' => int]] - byte offsets of complete card elements, in document
 * order, non-overlapping. Extend via the filter to support future providers.
 */
function allspice_recipe_card_adapters(): array {
    $adapters = [
        [
            'id' => 'wprm',
            'find_all' => 'allspice_gate_wprm_find_all',
        ],
    ];
    return apply_filters('allspice_recipe_card_adapters', $adapters);
}

/*
 * All WPRM cards in document order (req 13: deterministic for multiple cards). The existing
 * helper finds the FIRST card; iterate over suffixes, mapping offsets back to the full
 * string. Bounds cannot overlap because each scan resumes after the previous card's end.
 */
function allspice_gate_wprm_find_all(string $content): array {
    $found = [];
    $offset = 0;
    for ($i = 0; $i < 20; $i++) { // hard cap: no post has 20 recipe cards; guards a scan bug
        $slice = substr($content, $offset);
        if ($slice === '' || $slice === false) break;
        $bounds = allspice_find_wprm_recipe_bounds($slice);
        if (!$bounds) break;
        $found[] = [
            'start' => $offset + (int)$bounds['start'],
            'end' => $offset + (int)$bounds['end'],
        ];
        $offset = $offset + (int)$bounds['end'];
    }
    return $found;
}

/* --------------------------------------------------------------------------- gate decision */

/* Gated product ids for the current post's recipe, from the SYNCED overview row. */
function allspice_gate_current_recipe(): array {
    static $cached = null;
    if (is_array($cached)) return $cached;
    $cached = ['recipe_id' => '', 'gated' => []];
    if (!function_exists('allspice_is_recipe_page') || !allspice_is_recipe_page()) return $cached;
    if (!function_exists('allspice_current_url_key') || !function_exists('allspice_recipes_get_overview_by_url_key')) return $cached;
    $uk = allspice_current_url_key();
    if (!is_string($uk) || $uk === '') return $cached;
    $row = allspice_recipes_get_overview_by_url_key($uk);
    if (is_object($row)) $row = (array)$row;
    if (!is_array($row)) return $cached;
    /* PRODUCTION SYNC SHAPE: gated is a list of OBJECTS,
       [{ product_id: "product_123", gate_type: "recipe_card" }].
       Object entries only; select by gate_type === 'recipe_card' (never by array order or
       gates[0]); extract a clean non-empty product_id; dedupe. A string cast here is exactly
       the bug that produced "Array" in the placeholder. */
    $gated = [];
    if (isset($row['gated']) && is_array($row['gated'])) {
        foreach ($row['gated'] as $g) {
            if (!is_array($g)) continue;
            if (trim((string)($g['gate_type'] ?? '')) !== 'recipe_card') continue;
            $pid = trim((string)($g['product_id'] ?? $g['productId'] ?? $g['productID'] ?? ''));
            if ($pid !== '') $gated[] = $pid;
        }
    }
    $cached = [
        'recipe_id' => trim((string)($row['recipe_id'] ?? '')),
        'gated' => array_values(array_unique($gated)),
    ];
    return $cached;
}

function allspice_gate_memberships_active(): bool {
    /* Schema v2: configured = nonempty site_membership_id; legacy: enabled+ready. NOTE:
       new_signups_enabled has no effect here - enforcement and member access never depend
       on whether NEW purchases are open. */
    if (function_exists('allspice_memberships_configured')) return allspice_memberships_configured();
    if (!function_exists('allspice_memberships_config')) return false;
    $m = allspice_memberships_config();
    return ($m['enabled'] ?? null) === true && ($m['ready'] ?? null) === true;
}

/*
 * Fallback policy when a gated recipe's card CANNOT be safely located. Only WPRM is a
 * supported provider today, so during initial rollout the DEFAULT IS 'open': the recipe
 * renders, and a loud telemetry event records that gating did not activate - recipe-card
 * gating must never silently become an entire-post paywall on an unsupported provider.
 * 'closed' (replace the whole content with the gate) stays available strictly as an explicit
 * developer override via the filter, for publishers who prefer enforcement over availability.
 */
function allspice_gate_fallback_policy(): string {
    $policy = apply_filters('allspice_gate_fallback_policy', 'open');
    return $policy === 'closed' ? 'closed' : 'open';
}

/* ------------------------------------------------------------------------------ rendering */

function allspice_gate_placeholder_html(array $recipe): string {
    /* recipe_card copy from the normalized model - strictly this gate type's object (v2
       nested / legacy gates[] both handled by the ONE normalizer), never gates[0], never
       shared with the article gates. */
    $c = allspice_membership_gate_copy('recipe_card');
    $benefits = function_exists('allspice_membership_benefits_html')
        ? allspice_membership_benefits_html(allspice_membership_benefits_limit())
        : '';

    /* data-* payload: PUBLIC identifiers only (product ids are public config). The buttons
       are plain anchors carrying both the data-allspice-action hook AND the hash href, so
       they work through the page bundle's global hooks and - without JS - via hash routes. */
    $products_attr = esc_attr(implode(',', array_map('strval', $recipe['gated'])));
    return allspice_gate_embedded_css()
        . '<div class="allspice-recipe-gate" id="allspice-recipe-gate"'
        . ' data-allspice-gate-type="recipe_card"'
        . ' data-allspice-recipe-id="' . esc_attr($recipe['recipe_id']) . '"'
        . ' data-allspice-gate-products="' . $products_attr . '"'
        . ' data-allspice-gate-config-version="' . esc_attr(function_exists('allspice_memberships_config_version') ? allspice_memberships_config_version() : '') . '">'
        . '<div class="allspice-recipe-gate__inner">'
        . '<div class="allspice-recipe-gate__brand" data-allspice-gate-brand></div>'
        . '<h3 class="allspice-recipe-gate__title">' . esc_html($c['title']) . '</h3>'
        . '<p class="allspice-recipe-gate__copy">' . esc_html($c['copy']) . '</p>'
        . $benefits
        . allspice_membership_gate_actions_html($c['join_label'], $c['login_label'])
        . '<div class="allspice-recipe-gate__status" data-allspice-gate-status aria-live="polite"></div>'
        . '</div></div>';
}

/*
 * Lightweight gate stylesheet (req 14): inline on a registered handle - always available
 * when memberships are live, no dependency on the widget CSS or on the reader ever opening
 * Allspice, and no extra HTTP request.
 */
add_action('wp_enqueue_scripts', 'allspice_gate_enqueue_styles', 20);
function allspice_gate_enqueue_styles(): void {
    if (!allspice_gate_memberships_active()) return;
    wp_register_style('allspice-gate', false, [], defined('ALLSPICE_PLUGIN_VERSION') ? ALLSPICE_PLUGIN_VERSION : null);
    wp_enqueue_style('allspice-gate');
    wp_add_inline_style('allspice-gate', allspice_gate_css());
}

/*
 * The ONE gate stylesheet, shared by the enqueue above and the in-body embed below.
 * Includes the legacy membership-landing rules (formerly a separate inline handle in
 * membership-page.php) so every membership surface is covered by one payload.
 */
function allspice_gate_css(): string {
    return /* Base gate rules consume the --allspice-gate-* custom properties (emitted below
           from SANITIZED memberships.gate_style values only) with the original hardcoded
           look as the var() fallback - no config string ever lands in CSS unsanitized. */
        '.allspice-recipe-gate{border:var(--allspice-gate-border-width,1px) solid var(--allspice-gate-border,#e5e5ea);'
        . 'border-radius:var(--allspice-gate-radius,16px);padding:28px 22px;margin:24px 0;text-align:center;'
        . 'background:var(--allspice-gate-background,#fafafa);color:var(--allspice-gate-text,inherit);'
        . 'font-family:var(--allspice-gate-font-family,inherit)}'
        . '.allspice-recipe-gate__inner{max-width:460px;margin:0 auto}'
        /* display:block + margin auto, NOT inline centering: themes force `.entry-content img`
           to display:block, and a block image in a text-align:center card sits flush LEFT
           (live finding, baking4happiness 2026-08-26). Owning the display owns the centering. */
        . '.allspice-recipe-gate__brand img{max-height:44px;max-width:200px;object-fit:contain;display:block;margin:0 auto}'
        /* SPACING IS DEFENDED AT (0,3,0), not decorated. This card renders inside the
           publisher's theme, and a theme that scopes its typography (`.entry-content h3`
           and friends, a very common shape) sits at (0,2,0) and beat the gate's own
           (0,1,0) class rules - which is why the card rendered with ~42px above the title
           where 10px was specified, and read as loose and unfinished. Every rule that owns
           vertical rhythm is therefore scoped through __inner. */
        . '.allspice-recipe-gate .allspice-recipe-gate__inner .allspice-recipe-gate__brand{margin:0 0 16px}'
        . '.allspice-recipe-gate .allspice-recipe-gate__inner .allspice-recipe-gate__title{margin:0 0 6px;font-size:1.25em;line-height:1.3;color:var(--allspice-gate-heading,inherit)}'
        . '.allspice-recipe-gate .allspice-recipe-gate__inner .allspice-recipe-gate__copy{margin:0 0 20px;opacity:.8;line-height:1.5}'
        . '.allspice-recipe-gate__actions{display:flex;flex-direction:column;gap:12px;align-items:center}'
        . '.allspice-recipe-gate__join{display:inline-block;padding:11px 26px;border-radius:999px;'
        . 'background:var(--allspice-gate-button-background,#1f2430);color:var(--allspice-gate-button-text,#fff) !important;'
        . 'text-decoration:none !important;font-weight:600;cursor:pointer}'
        . '.allspice-recipe-gate__login{font-size:.95em;text-decoration:underline;cursor:pointer}'
        . '.allspice-recipe-gate__closed{margin:0;font-size:.95em;opacity:.8}'
        . '.allspice-recipe-gate__status{min-height:1.2em;margin-top:10px;font-size:.9em;opacity:.75}'
        /* ...but not when there is nothing to say. min-height reserved a line plus its
           margin under the CTA on every render, which is the dead band at the foot of the
           card. It still reserves space once populated, so a message appearing does not
           shift the layout. */
        . '.allspice-recipe-gate__status:empty{display:none}'
        . '.allspice-recipe-gate.is-loading .allspice-recipe-gate__actions{opacity:.5;pointer-events:none}'
        /* Benefits checklist (gates, landing, CTA block): responsive single column, inline
           SVG check inherits the gate text color. */
        . '.allspice-membership-benefits{list-style:none;padding:0;margin:0 auto 16px;max-width:420px;text-align:left}'
        . '.allspice-membership-benefits li{display:flex;align-items:flex-start;gap:8px;margin:8px 0}'
        /* Same defence for the list, which a theme also styles by element. */
        /* List layout re-asserted at the defended (0,3,0) scope: theme `.entry-content ul/li`
           rules at (0,2,0) re-add bullets, padding and list-item display over the base
           (0,1,0) rules above. */
        . '.allspice-recipe-gate .allspice-recipe-gate__inner .allspice-membership-benefits{margin:0 auto 22px;list-style:none;padding:0}'
        . '.allspice-recipe-gate .allspice-recipe-gate__inner .allspice-membership-benefits li{margin:0 0 12px;display:flex;align-items:flex-start;gap:10px;padding:0;list-style:none}'
        . '.allspice-recipe-gate .allspice-recipe-gate__inner .allspice-membership-benefits li:last-child{margin-bottom:0}'
        /* Centered on the FIRST TEXT LINE whatever the theme's type metrics: the fixed
           margin-top:2px assumed small type, and against an 18px/1.6 theme the check rode
           visibly high (live finding, baking4happiness 2026-08-26). The lh-based margin
           computes the exact first-line offset; the em value approximates it for engines
           without lh units. */
        . '.allspice-membership-benefits__check{flex:0 0 auto;display:inline-flex;margin-top:.3em;margin-top:calc((1lh - 16px)/2);color:var(--allspice-gate-button-background,#1f2430)}'
        . '.allspice-membership-benefits__check svg{display:block;width:16px;height:16px}'
        . '.allspice-membership-benefits__text{min-width:0}'
        . '.allspice-membership-benefits__text strong{display:block;font-weight:600}'
        . '.allspice-membership-benefits__desc{display:block;font-size:.9em;opacity:.75}'
        /* Soft-overlay gate (default content-gate mode): clamp the lead-in, fade it into the
           gate background, and let the page bundle lock scroll once the gate is reached.
           content_immediate shows only a teaser sliver; content_preview a tall lead-in. */
        . '.allspice-soft-gate{position:relative}'
        . '.allspice-soft-gate__content{overflow:hidden;pointer-events:none;user-select:none;-webkit-user-select:none}'
        . '.allspice-soft-gate--immediate .allspice-soft-gate__content{max-height:24vh}'
        . '.allspice-soft-gate--preview .allspice-soft-gate__content{max-height:72vh}'
        . '.allspice-soft-gate__fade{position:relative;height:150px;margin-top:-150px;pointer-events:none;'
        . 'background:linear-gradient(to bottom,rgba(255,255,255,0) 0%,var(--allspice-gate-background,#fafafa) 88%)}'
        . '.allspice-soft-gate .allspice-recipe-gate{margin-top:0}'
        /* Armed by the page bundle on a gated page. The stop itself is a JS clamp on DOWNWARD
           travel only (so the reader can always scroll back up); this rule just stops the
           rubber-band/scroll-chaining at the stop so the clamp settles instead of bouncing.
           Deliberately NOT `overflow:hidden` - that froze both directions, and a stale cached
           bundle applying the old `asx-soft-gate-locked` class now matches nothing (fail-open:
           the content above stays CSS-clamped either way) rather than freezing the page. */
        . 'html.asx-soft-gate-armed{overscroll-behavior-y:none}'
        . allspice_gate_style_vars_css()
        /* Legacy [allspice_membership] landing wrapper (membership-page.php). */
        . '.allspice-membership-page{max-width:560px;margin:0 auto;padding:8px 4px;text-align:center}'
        . '.allspice-membership-page__brand img{max-height:52px;max-width:220px;object-fit:contain}'
        . '.allspice-membership-page__title{margin:10px 0 6px}'
        . '.allspice-membership-page__copy{opacity:.85;margin:0 0 14px}'
        . '.allspice-membership-page__benefits{list-style:none;padding:0;margin:0 auto 18px;display:inline-block;text-align:left}'
        . '.allspice-membership-page__benefits li{margin:6px 0;padding-left:22px;position:relative}'
        . '.allspice-membership-page__benefits li:before{content:"";position:absolute;left:0;top:.25em;width:13px;height:13px;background:currentColor;-webkit-mask:url(\'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M4.5 10.5l3.4 3.4 7.6-8" fill="none" stroke="black" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>\') no-repeat center/contain;mask:url(\'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M4.5 10.5l3.4 3.4 7.6-8" fill="none" stroke="black" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>\') no-repeat center/contain}'
        . '.allspice-membership-page__actions{display:flex;flex-direction:column;gap:10px;align-items:center}'
        . '.allspice-membership-page__manage{margin-top:22px;font-size:.95em}'
        . '.allspice-membership-page__manage a{display:inline-block;padding:9px 20px;'
        . 'border:1px solid currentColor;border-radius:999px;text-decoration:none;'
        . 'line-height:1.2;opacity:.7;transition:opacity .15s ease}'
        . '.allspice-membership-page__manage a:hover,'
        . '.allspice-membership-page__manage a:focus-visible{opacity:1}'
        . '@media(min-width:600px){.allspice-membership-page{padding:16px 0}}'
        /* Signed-in-but-not-a-member chip: replaces "Already a member? Log in" once the
           widget reports a real login (membership-runtime.js swaps them). */
        . '.allspice-membership-signedin{display:inline-flex;align-items:center;gap:6px;'
        . 'opacity:.75;font-size:.95em;line-height:1.2}'
        . '.allspice-membership-signedin svg{display:block}';
}

/*
 * OPTIMIZER ARMOR (seen live on baking4happiness 2026-08-25): used-CSS optimizers empty
 * head inline-style tags they judge unused, and ours was gutted - naked gate card, glued
 * CTA buttons, misaligned benefit checks, and (worst) the CSS teaser clamp gone, so the
 * whole server-included lead-in showed unclamped. Protected content itself is removed
 * server-side and was never exposed, but the paywall must LOOK like a paywall regardless
 * of what an optimizer does to the head. So the first gate/CTA markup on a page carries
 * the same stylesheet as an IN-BODY <style>, which those optimizers leave alone. On
 * unoptimized sites this duplicates a few KB of rules - identical selectors, identical
 * values, zero visual effect - which is the price of rendering correctly everywhere.
 */
function allspice_gate_embedded_css(): string {
    static $done = false;
    if ($done) return '';
    $done = true;
    return '<style id="allspice-gate-embedded-css">' . allspice_gate_css()
        . (function_exists('allspice_membership_cta_css') ? allspice_membership_cta_css() : '')
        . '</style>';
}


/*
 * The --allspice-gate-* custom-property block from SANITIZED gate_style values. Only values
 * that passed the strict sanitizers are emitted; everything else falls back to the defaults
 * baked into the base rules above. Never applied to protected article content - the
 * selectors cover the GATE containers only (allspice-paywalled-content is untouched).
 */
function allspice_gate_style_vars_css(): string {
    if (!function_exists('allspice_memberships_normalized')) return '';
    $s = allspice_memberships_normalized()['gate_style'];
    $vars = [];
    if ($s['background_color'] !== null) $vars[] = '--allspice-gate-background:' . $s['background_color'];
    if ($s['text_color'] !== null) $vars[] = '--allspice-gate-text:' . $s['text_color'];
    if ($s['heading_color'] !== null) $vars[] = '--allspice-gate-heading:' . $s['heading_color'];
    if ($s['border_color'] !== null) $vars[] = '--allspice-gate-border:' . $s['border_color'];
    if ($s['border_width'] !== null) $vars[] = '--allspice-gate-border-width:' . $s['border_width'] . 'px';
    if ($s['corner_radius'] !== null) $vars[] = '--allspice-gate-radius:' . $s['corner_radius'] . 'px';
    if ($s['button_background_color'] !== null) $vars[] = '--allspice-gate-button-background:' . $s['button_background_color'];
    if ($s['button_text_color'] !== null) $vars[] = '--allspice-gate-button-text:' . $s['button_text_color'];
    if ($s['font_family'] !== null) $vars[] = '--allspice-gate-font-family:' . $s['font_family'];
    if ($vars === []) return '';
    return '.allspice-recipe-gate,.allspice-content-gate{' . implode(';', $vars) . '}';
}

/* -------------------------------------------------------------------------- enforcement */

/*
 * Priority 99: after shortcodes (WPRM renders at the default 10-ish), so the card markup the
 * adapters scan is the FINAL markup.
 */
add_filter('the_content', 'allspice_gate_filter_content', 99);
function allspice_gate_filter_content($content) {
    if (!is_string($content) || $content === '') return $content;
    if (is_feed() || is_admin()) return $content;
    /* Singular content only - never widgets, excerpts, feeds. Identity guard instead of
       loop-state (in_the_loop/is_main_query): custom CPT templates render the_content from
       secondary queries where loop state lies but the post id still tells the truth. Same
       theme-compat finding as allspice_content_enforce_filter. */
    if (!is_singular()) return $content;
    $post_id = (int)get_the_ID();
    $queried = (int)get_queried_object_id();
    if ($post_id <= 0 || $queried <= 0 || $post_id !== $queried) return $content;

    /* Fail OPEN when memberships are disabled or not ready (req 8) - gating simply off. */
    if (!allspice_gate_memberships_active()) return $content;

    /* A whole-content gate (content-enforce.php) already replaced/reduced this content for
       an unauthorized reader - never render the recipe-card gate on top of it. Authorized
       members fall through and get normal recipe-card behavior. */
    if (function_exists('allspice_content_gate_overrides_recipe') && allspice_content_gate_overrides_recipe()) {
        return $content;
    }

    $recipe = allspice_gate_current_recipe();
    if ($recipe['gated'] === []) return $content;

    /* OR rule: any matching product in the VERIFIED member session grants access (req 3). */
    if (function_exists('allspice_member_has_any_product') && allspice_member_has_any_product($recipe['gated'])) {
        return $content; // untouched (req 4)
    }

    /* Locate every card via adapters, replace each with the gate (first card gets the full
       gate; later duplicates collapse to nothing so one article never shows two gates). */
    $all = [];
    foreach (allspice_recipe_card_adapters() as $adapter) {
        if (!is_array($adapter) || !is_callable($adapter['find_all'] ?? null)) continue;
        $found = call_user_func($adapter['find_all'], $content);
        if (is_array($found)) {
            foreach ($found as $b) {
                if (is_array($b) && isset($b['start'], $b['end']) && $b['end'] > $b['start']) {
                    $all[] = ['start' => (int)$b['start'], 'end' => (int)$b['end']];
                }
            }
        }
    }

    if ($all === []) {
        /* Gated recipe, card not safely locatable (req 9): log + follow the policy filter. */
        if (function_exists('allspice_console_log')) {
            /* Telemetry: unsupported provider / boundary failure - gating did not activate. */
            allspice_console_log('[Allspice] GATE ERROR: gated recipe but no recipe card located (rule not activated)', [
                'recipe_id' => $recipe['recipe_id'],
                'policy' => allspice_gate_fallback_policy(),
            ]);
        }
        if (allspice_gate_fallback_policy() === 'open') return $content;
        return allspice_gate_placeholder_html($recipe);
    }

    /* Deterministic multi-card handling: sort by position, drop overlaps, replace from the
       END backwards so earlier offsets stay valid. */
    usort($all, static function ($a, $b) { return $a['start'] <=> $b['start']; });
    $kept = [];
    $cursor = -1;
    foreach ($all as $b) {
        if ($b['start'] < $cursor) continue; // overlap from a second adapter: first wins
        $kept[] = $b;
        $cursor = $b['end'];
    }
    $gate_html = allspice_gate_placeholder_html($recipe);
    for ($i = count($kept) - 1; $i >= 0; $i--) {
        $replacement = ($i === 0) ? $gate_html : '';
        $content = substr($content, 0, $kept[$i]['start'])
            . $replacement
            . substr($content, $kept[$i]['end']);
    }
    return $content;
}